Skip to content

Add json_document and json_view: node index, parser, and document - #5620

Merged
nlohmann merged 32 commits into
developfrom
json-view/08-view-builder
Oct 21, 2026
Merged

nlohmann merged 32 commits into
developfrom
json-view/08-view-builder

Conversation

@nlohmann

@nlohmann nlohmann commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Part of the stack for the zero-copy view (#5295). This adds the internal node index and parser, and the public json_document/json_view classes built on it. Element access, values, dump(), and comparisons follow in later PRs, so each one stays reviewable.

#include <nlohmann/json_view.hpp>

std::string text = receive();
auto doc = nlohmann::json_document::parse(text);   // borrows text
nlohmann::json_view root = doc.root();
if (root.is_object() && !root.empty())
{
    nlohmann::json j = root.materialize();          // the value json::parse(text) returns
}

Summary

  • A parse produces a flat array of 16-byte nodes in document order: one node per value, and one per object key.
    • Strings stay in the source text, and escaped strings are decoded into an arena.
    • Integers are converted while their digits are in the cache.
    • Floats keep only their digit layout; they are converted when read, with the library's float converter from Speed up the lexer: own float parser, string scan, and \u table #5738 (independent of the locale, no allocation).
    • Containers store the size of their subtree, so a reader can step over one in constant time.
    • Nothing is allocated per value: a document makes a handful of allocations, however many values it has. The node count is checked against max_nodes() when the index grows; beyond it, std::bad_alloc is thrown.
  • The parser accepts exactly what json::parse accepts: every combination of ignore_comments and ignore_trailing_commas, with and without a trailing NUL, and under JSON_STRICT_NUL_HANDLING. It is portable C++11, and nothing depends on the byte order: words are read in a fixed order, node fields are written explicitly, and literals are compared with memcmp. The SIMD kernels come later in the stack.
  • The parse state lives in a local cursor whose address never escapes, so it stays in registers; cold paths (errors, regrowth, escapes, comments) are out-of-line members.
  • basic_json_document<BasicJsonType>: parse, parse_copy, accept, read (reuses the document's memory), root, is_discarded, source, owns_source, node_count, memory_usage, shrink_to_fit.
  • basic_json_view<BasicJsonType>: type, the is_* queries, size, empty, materialize, source_offset.
  • Aliases: json_document, json_view, ordered_json_document, ordered_json_view.
  • Input: borrowed (lvalue contiguous byte containers, std::string_view, C strings, character arrays, pointer ranges, and in C++20 other contiguous iterator ranges) or owned (a non-const rvalue std::string, moved in; a const rvalue std::string and other rvalue byte containers, copied; streams and wide strings, read into a buffer). A FILE* and pointers to or arrays of wide characters are rejected at compile time.
    • parse, parse_copy, accept, and read with an integer as the second argument are deleted: parse(ptr, len) would silently convert len to allow_exceptions and read ptr with strlen. Use parse(ptr, ptr + len).
  • Errors: a parse error throws the exception basic_json::parse would throw for the same input. The library parser runs on the failing input (a cold path), so message, position, and id match.
  • Input limit: inputs of 4294967280 bytes (4 GiB minus 16) or more are rejected with the new out_of_range.416 ("input of 4294967280 bytes or more is not supported by json_document").
  • materialize(): builds the value with the SAX handler parse() uses, so JSON_DIAGNOSTICS parent pointers are set as usual.
  • root() of a temporary document is deleted: the view would dangle. Give the document a name first.
  • detail::abi_config keeps JSON_STRICT_NUL_HANDLING readable after json.hpp undefines it at its end, in the ABI namespace, which already encodes the setting, so it always matches the basic_json in use.
  • Docs and tooling: a feature page, API pages and examples for both classes, the architecture description of the node index, out_of_range.416 in exceptions.md, the C++20 module exports, an amalgamated single_include/nlohmann/json_view.hpp (make amalgamate, check_amalgamation), a fuzzer, the benchmarks, Bazel, and the aspect: json_view label in labeler.yml.

Performance

Measured on Apple M1 Max (Apple clang, -O2) and an x86-64 KVM Haswell VPS pinned to one core (GCC 13 / Clang 18, -O2; the VPS is noisy, about ±5-10%). µs, best of 3 interleaved rounds of 15 runs; files from nativejson-benchmark. json_document::parse vs json::parse (both at this PR):

Apple M1 x86-64 GCC x86-64 Clang
canada 1012 vs 7633 (7.5×) 1991 vs 14672 (7.4×) 2239 vs 15589 (7.0×)
citm_catalog 390 vs 2961 (7.6×) 877 vs 7646 (8.7×) 929 vs 6722 (7.2×)
twitter 244 vs 1413 (5.8×) 538 vs 4027 (7.5×) 508 vs 4024 (7.9×)

The core library is unaffected: this PR only adds code under include/nlohmann/detail/view/ and the new public header json_view.hpp. json.hpp only gains the internal detail::abi_config constant and one include.

Tests

  • unit-json_view_builder.cpp: the node index compared with json::parse for handwritten, generated, and damaged documents under all option combinations; input from std::string and from exact-size buffers with no read past the input under AddressSanitizer; deep nesting up to 100,000 levels; NUL, BOM, and whitespace cases; node integer bits and the array size limit; and the test-suite files.
  • unit-json_view.cpp: materialize() equals parse() for handwritten and generated documents, with duplicate keys, deep nesting, and JSON_DIAGNOSTICS parents; parse errors equal parse()'s, message included, for malformed inputs under all option combinations; every input kind, borrowed or owned; integer arguments and root() of a temporary document do not compile; the input size limit; reuse with read(), moves, shrink_to_fit(), and source offsets.
  • unit-json_view_macros.cpp: includes the header without JSON_TEST_KEEP_MACROS, as users do, and checks that the view relies on no macro json.hpp undefines and leaks none of its own NLOHMANN_VIEW_* macros.
  • Fuzzer (fuzzer-parse_json_view.cpp): accept, value, and exception parity with json::parse, for a std::string and an exact-size byte vector, with and without comments and trailing commas.

Public API

No breaking changes. Everything is new and additive; nothing existing changes. The view API is new in this stack, so the following decisions are not breaking relative to a release, but they are visible to users:

  • New header <nlohmann/json_view.hpp> with basic_json_document, basic_json_view, and the aliases json_document, json_view, ordered_json_document, ordered_json_view.
  • json_view has no operator bool: write !v.is_discarded().
  • root() of an rvalue document is deleted (it would dangle); use a named document.
  • parse, parse_copy, accept, and read with an integer second argument do not compile (deleted overloads).
  • A const rvalue std::string is copied, not moved.
  • A new exception id, out_of_range.416 (input of 4294967280 bytes or more).
  • basic_json_document<BasicJsonType> requires 64-bit integer types (static_assert).
  • The C++20 module exports the new names.
  • json.hpp gains the internal detail::abi_config constant (not part of the public API).

Written by Claude Code.

🤖 Generated with Claude Code

@nlohmann
nlohmann added this pull request to stack #5636 September 29, 2026 14:20
@nlohmann nlohmann changed the title json view/08 view builder Add the node index and parser of json_view (internal) Sep 29, 2026
@nlohmann

Copy link
Copy Markdown
Owner Author

CI fixes for this PR (commits 87a041a and ab4f646):

  • msvc (Debug, Win32, default): C4127 (conditional expression is constant) for TrailingCommas && cur() == ']', ... == '}' and Comments && cur() == '/' in detail/view/builder.hpp when the option is off. The template arguments now go through a static enabled(bool) function, the same approach as nesting_depth_exhausted() in json.hpp. Not reproduced locally (no MSVC here).
  • ci_test_single_header: unit-json_view_builder.cpp includes nlohmann/detail/view/*.hpp, which is not in single_include/. At this point in the stack, the test is built only with multiple headers. From Add json_document and json_view: parse, accept, types, materialize #5621 on, it includes <nlohmann/json_view.hpp> in single-header mode instead.
  • ci_test_gcc (not visible before, because the build stopped at an earlier error): -Werror=useless-cast on static_cast<std::size_t>(guess + (guess / 4) + 64) in builder::grow(), since std::uint64_t is std::size_t on Linux x86-64. The sum is now cast through a named variable. GCC does not report a cast of an lvalue.
  • The remaining GCC failures were inherited from Convert floats with Eisel-Lemire when std::from_chars is unavailable #5617/Find the stop byte of a string run without a byte loop #5618.

Verified with the CI's own image gcc:16 (linux/amd64) and the CI warning flags, for C++11 and C++20: the builder test builds and passes. A CMake configure with -DJSON_MultipleHeaders=OFF no longer lists the builder test.

— posted by Claude Code on behalf of @nlohmann

Comment thread include/nlohmann/detail/view/builder.hpp Fixed
Comment thread include/nlohmann/detail/view/builder.hpp Fixed
Comment thread include/nlohmann/detail/view/builder.hpp Dismissed
Comment thread include/nlohmann/detail/view/builder.hpp Dismissed
Comment thread include/nlohmann/detail/view/builder.hpp Fixed
Comment thread include/nlohmann/detail/view/document_data.hpp Fixed
Comment thread include/nlohmann/detail/view/node.hpp Dismissed
Comment thread include/nlohmann/detail/view/node.hpp Dismissed
Comment thread include/nlohmann/detail/view/scan.hpp Dismissed
Comment thread include/nlohmann/detail/view/string_ref.hpp Fixed
@github-actions github-actions Bot added the CMake label Sep 30, 2026
@nlohmann
nlohmann removed this pull request from stack #5636 September 30, 2026 13:19
@nlohmann
nlohmann force-pushed the json-view/08-view-builder branch from ab4f646 to dc9899f Compare September 30, 2026 13:20
@nlohmann
nlohmann added this pull request to stack #5739 September 30, 2026 13:21
@nlohmann
nlohmann force-pushed the json-view/08-view-builder branch 3 times, most recently from 7030188 to c195988 Compare September 30, 2026 18:06
@nlohmann
nlohmann marked this pull request as ready for review September 30, 2026 18:18
@nlohmann
nlohmann force-pushed the json-view/08-view-builder branch from c195988 to e96e298 Compare September 30, 2026 18:19
@nlohmann
nlohmann removed this pull request from stack #5739 October 6, 2026 09:26
@nlohmann
nlohmann force-pushed the json-view/08-view-builder branch from 2739c0b to 4303ba6 Compare October 6, 2026 09:28
@nlohmann
nlohmann changed the base branch from json-view/04-unicode-escapes to json-view/23-zmij October 6, 2026 09:29
@nlohmann nlohmann changed the title Add the node index and parser of json_view (internal) Add json_document and json_view: node index, parser, and document Oct 6, 2026
@nlohmann
nlohmann added this pull request to stack #5768 October 6, 2026 09:29
@nlohmann
nlohmann force-pushed the json-view/08-view-builder branch from 4303ba6 to 759dd2e Compare October 7, 2026 14:44
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

🔴 Amalgamation check failed! 🔴

The source code has not been amalgamated and/or formatted correctly, or BUILD.bazel is out of date.

📎 A ready-to-apply patch is attached to the failed workflow run as the amalgamation-patch artifact. Download it, then apply it locally from the repository root with:

git apply amalgamation.patch

This does not require installing astyle yourself.

@nlohmann nlohmann closed this Oct 10, 2026
json_document::parse(std::move(const_string)) failed to compile with
"no matching read_kind": only a non-const rvalue std::string can be moved
from. Treat a const rvalue as a copied byte container.

parse() does not accept everything BasicJsonType::parse() does: a FILE*
and pointers to or arrays of wide characters are rejected at compile time.
List the supported inputs instead.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
explicit operator bool meant "refers to a value", which silently differs
from what a basic_json converts to. Use !v.is_discarded() instead.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
auto v = json_document::parse(text).root() compiled and left the view
dangling. Delete the overload for rvalue documents, take a named document
in the tests, and document the lifetime rule.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The non-little-endian path of the node writer wrote the integer's native
word over len and next, so len got the high half there. Compose and split
the value explicitly (len is the low half, next the high half); the
little-endian path stays a plain memcpy.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
reserve(n) and the growth of the index computed n * sizeof(node) without a
check, which wraps around on 32-bit targets for inputs of about 1 GiB and
allocates a too small array. Throw std::bad_alloc for a count beyond the
address space and clamp the growth step to it.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The check rejects inputs of 0xFFFFFFF0 bytes or more, but the exception
message and the documentation said 4 GiB. Name the limit once
(max_input_size), and state 4 GiB minus 16 bytes in the message and the
documentation. Test the limit with a container that only claims the size.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Nothing reads it: the view does not compare values.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The fuzzer only parsed a std::string with default options. Also parse an
exact-size byte vector, which has no NUL after its last byte and takes the
bounds-checked path, and derive ignore_comments and ignore_trailing_commas
from the first input byte, comparing against json::parse and json::accept
with the same options.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
ci_single_binaries runs IWYU with --error on every header. For json_view.hpp
it suggested adding <array> (std::array is used), the headers that json.hpp
already provides (abi_config, abi_macros, input_adapters, json_pointer,
cpp_future, string_concat, value_t, json_fwd), and <version> for
std::nullptr_t, and removing <cstddef>.

- include <array>
- keep <cstddef> (nullptr_t, size_t; IWYU attributes them to <version> and <cstring>)
- tell IWYU not to suggest the headers that json.hpp provides: the amalgamated
  json_view.hpp only includes json.hpp, so including them here would duplicate
  their definitions
- export json.hpp, keep macro_unscope.hpp, and drop the unused forward declaration of the document class

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
emit() moves the node array when it grows, and the subtraction read base
in the same expression, so the order was unspecified. MSVC Release builds
without forced inlining read the old base; the container index then
pointed outside the array and close() wrote out of bounds.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
const_text is const, so std::move does not move from it; the second call
is part of the const rvalue test.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Changes promised in the replies to the review of #5620:

- read() reuses the owned copy: a copied input (an rvalue byte container,
  a stream, FILE*, wide characters, an iterator range) is appended to the
  cleared owned buffer instead of a fresh std::string being move-assigned
  over it. A moved-in std::string still replaces it.
- The error replay runs the library parser with a SAX handler that stores
  nothing and throws the reported exception, instead of
  BasicJsonType::parse(), which built a DOM up to the error.
- Pointers to and arrays of wide characters, and FILE*, take the input
  adapter path (converted to UTF-8 into an owned buffer) instead of
  failing a static_assert. Wide pointers are NUL-terminated.
- Streams are read in blocks through rdbuf() into the buffer, instead of
  character by character through the input adapter; eofbit is set at the
  end, as by the library's stream adapter.
- tape/tape_size/tape_cap are now nodes/node_count/node_capacity.
- The builder's members get descriptive, m_-prefixed names.
- The shallow frame stack is a std::array (in a wrapper with a
  user-provided constructor: -Weffc++ wants the member initialized, and
  value-initializing the 64 frames costs up to 30 % on small documents).
- The value dispatch macro has no NEXT parameter; the switch is left with
  break, and the macro is undefined after its last use.
- literal() and digits_exceed() deduce the length from the string literal;
  each literal has one helper; the two `if (!is_float)` blocks are merged.
- arena_ensure() sits right before the writes; the decode arena is a
  std::vector<char>.
- The builder records the deepest nesting, and materialize() sizes its
  stack from it once (growing it only if a subtree is deeper).
- Comment fixes (hex4, slow_string, the NUL rule, put_utf8, 0 - m, the
  arena resize, grow()) and formatting (macro backslashes, digit cases,
  one power of ten per line, value_t assertion next to is_container()).
- build_with's template parameters follow the builder's order.

Greg's idea of keeping the source offset in `off` and the arena offset in
`next` for escaped strings was measured and not adopted: string access got
7-18 % slower with a select, and 3.5-18 % slower with a branch-free field
pick (Apple M-series, clang -O3), parsing was unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
- Copying is disabled because of its cost, not because of two documents
  borrowing the same buffer; the unique_ptr follows from move-only.
- Inputs: streams, FILE*, and wide characters (strings, arrays, pointers)
  are all supported and owned; point to parse_copy() for owned copies.
- The error replay only runs for failing input, builds no values, and is
  skipped with allow_exceptions = false.
- read() writes copied inputs into the previous owned copy.
- Views can dangle like std::string_view, and why there is no
  shared/weak pointer design.
- The 4 GiB limit (16-byte nodes, 16-byte scanner steps), streams being
  read to the end first, which macros apply, why materialize() has no
  source positions, and wording nits (accept() vs basic_json::accept(),
  is_binary, is_discarded, see-also lists, size table, "back into").

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
…son_view

- inline_nodes is null when the document has no inline nodes, instead of
  pointing past the end of the allocation; shrink_to_fit() no longer
  passes it to memcpy() for an empty node array
- is_byte_char and is_wide_char use conjunction/disjunction, which
  astyle leaves unspaced
- append_stream() uses (std::max) for the count read

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The test calls detail::view::materialize(), which only the single header
pulled in; the multiple-header build (JSON_TEST_USING_MULTIPLE_HEADERS)
failed with "'materialize' is not a member of detail::view".

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Qt defines emit as an empty macro, so a Qt header included before
json_view.hpp broke the declaration of the builder's emit(), see
issue #5865. It is now called push_node().

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
memcpy() must not be passed a null pointer even for zero bytes, which
arena_run() did for an empty run while the arena was still empty
(ci_test_clang_sanitizer: "null pointer passed as argument 1" in
builder.hpp). Only the slow path can see such a run.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Infer's std::unique_ptr model confuses the values that the test's build()
helper returns by value and reports USE_AFTER_DELETE for one of them after
the destructor of an unrelated temporary (ci_infer, 12 reports, all in
tests/src/unit-json_view_builder.cpp). AddressSanitizer is clean on the
same tests.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
MSVC 2015 does not treat selecting a deleted function in decltype as a
substitution failure, so is_detected reports the deleted
parse/parse_copy/accept/read(ptr, len) overloads as callable and the
static_asserts in "integer arguments do not compile" fail (AppVeyor
VS2015 jobs). Calling them still fails to compile there. Guard the
negative checks as unit-delete_deprecated_functions.cpp does.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants